feat: History log logic for Components and Containers [FC-0123]#38178
feat: History log logic for Components and Containers [FC-0123]#38178ChrisChV wants to merge 31 commits intoopenedx:masterfrom
Conversation
|
Thanks for the pull request, @ChrisChV! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
| path('assets/', blocks.LibraryBlockAssetListView.as_view()), | ||
| path('assets/<path:file_path>', blocks.LibraryBlockAssetView.as_view()), | ||
| path('publish/', blocks.LibraryBlockPublishView.as_view()), | ||
| # Get the draft change history for this block |
There was a problem hiding this comment.
Question: Can these endpoints be moved to xblock/rest_api and abstract the library logic?
I'm afraid no, but we will probably need to implement something like this there, for course xblocks, right?
There was a problem hiding this comment.
I'm afraid there's no time to do it now; we can do it when this is implemented for the courses.
* Add get_library_container_publish_history and
get_library_container_publish_history_entries to the containers API,
returning groups (container + descendant components) sorted newest-first
* Add entity_key field to LibraryPublishHistoryGroup so the frontend
knows which entries endpoint to call per group; block_type is now
nullable to support containers
* Replace per-block publish_history/<uuid>/entries/ route with a single
library-level publish_history_entries/ endpoint that accepts entity_key
and publish_log_uuid as query params and routes to the correct handler
…s and scope_entity_key [FC-0123]
Redesigns publish history groups to support the `direct` field on PublishLogRecord (added in Verawood). Key changes:
- Replace `entity_key/title/block_type` in `LibraryPublishHistoryGroup`
with `direct_published_entities: list[DirectPublishedEntity]`, each
carrying `entity_key`, `title`, and `entity_type` (works for both
components and containers).
- Add `scope_entity_key: str | None` to each group so the frontend
always knows which key to pass to the entries endpoint without needing
era awareness (`group.scope_entity_key ?? currentContainerKey`).
- Pre-Verawood (direct=None): one group per entity × publish event;
`scope_entity_key` = that entity's key.
- Post-Verawood (direct!=None): one merged group per PublishLog;
`scope_entity_key` = null (frontend uses current container).
- Rename `block_type` → `item_type` (non-nullable) in
`LibraryHistoryEntry`; populate it for containers via
`container_type.type_code` (e.g. "unit", "section").
- Rename entries query param `entity_key` → `scope_entity_key`.
- Add Pre-Verawood and Post-Verawood integration tests.
| openedx-atlas # CLI tool to manage translations | ||
| openedx-calc # Library supporting mathematical calculations for Open edX | ||
| openedx-core # Open edX Core: Content, Tagging, and other foundational APIs | ||
| git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log#egg=openedx-core |
There was a problem hiding this comment.
TODO: Update this with the new version
ormsbee
left a comment
There was a problem hiding this comment.
I still have a lot more to review, but I wanted to submit this partial review before I turned in for today. Will look through this more in the morning.
| Pre-Verawood groups have exactly one entry (approximated from available data). | ||
| Post-Verawood groups have one entry per direct=True record in the PublishLog. | ||
| """ | ||
| entity_key: str # str(usage_key) for components, str(container_key) for containers |
There was a problem hiding this comment.
Why make this a str instead of a LibraryUsageLocatorV2?
| Pre-Verawood (direct=None): one group per entity × publish event. | ||
| Post-Verawood (direct!=None): one group per unique PublishLog. | ||
| """ | ||
| publish_log_uuid: str |
There was a problem hiding this comment.
Similar above, why not have this be a UUID?
| # Pre-Verawood: the specific entity key for this group (container or usage key). | ||
| # Post-Verawood container groups: None — frontend must use currentContainerKey. | ||
| # Component history (all eras): str(usage_key). | ||
| scope_entity_key: str | None |
There was a problem hiding this comment.
Can this be a union of the acceptable key types?
| Post-Verawood (direct!=None): one group per unique PublishLog. | ||
| """ | ||
| publish_log_uuid: str | ||
| published_by: object # AUTH_USER_MODEL instance or None |
There was a problem hiding this comment.
@kdmccormick: What's the right way to do this if we want to be respectful of custom user models? I vaguely recall you went down this rabbit hole once.
There was a problem hiding this comment.
Actually, I think openedx-platform has too many assumptions about our user model for custom user models to work anyway, so maybe we can just make this a User?
| def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor | None]: | ||
| """ | ||
| Convert an iterable of User objects (possibly containing None) to a list of | ||
| LibraryHistoryContributor. |
There was a problem hiding this comment.
Please put more information in the docstring about how this function is expected to behave, such as:
- What ordering guarantees does it make?
- Will it remove duplicates?
- Will it return
Noneentries in the output?
| ] | ||
|
|
||
|
|
||
| def resolve_change_action(old_version, new_version) -> str: |
There was a problem hiding this comment.
Please annotate what types old_version and new_version are supposed to be.
| Returns "renamed" when both versions exist and the title changed between | ||
| them; otherwise returns "edited" as the default action. |
There was a problem hiding this comment.
Are creation and deletion out of scope?
There was a problem hiding this comment.
Sorry I updated the code to return created and deleted entries in: cb03488
| # Import here to avoid circular imports (container_metadata imports block_metadata). | ||
| from .container_metadata import library_container_locator # noqa: PLC0415 | ||
|
|
||
| title = record.new_version.title if record.new_version else "" |
There was a problem hiding this comment.
Does that mean that deletion is going to show up in the log as:
"" was edited
Because the new_version is going to be null?
| try: | ||
| component = record.entity.component | ||
| return DirectPublishedEntity( | ||
| entity_key=str(LibraryUsageLocatorV2( # type: ignore[abstract] |
There was a problem hiding this comment.
Why is ignore[abstract] necessary?
There was a problem hiding this comment.
LibraryUsageLocatorV2 implements all the abstract methods at runtime, but opaque_keys doesn't ship complete type stubs, so mypy can't verify the implementation and complains. The ignore is necessary here.
| title=title, | ||
| entity_type=component.component_type.name, | ||
| ) | ||
| except Component.DoesNotExist: |
There was a problem hiding this comment.
Please do checks with hasattr() for container vs. component, and branch accordingly. Having the container code path be an error handling fallback of component handling is awkward and will break when we have entities that are neither Components nor Containers.
… resolve_contributor
ormsbee
left a comment
There was a problem hiding this comment.
Some more questions/suggestions on how LibraryHistoryContributor is handled.
| """ | ||
| One entry in the history of a library component. | ||
| """ | ||
| changed_by: LibraryHistoryContributor | None |
There was a problem hiding this comment.
Everywhere else in the API, changed_by is a User, but here it's a new kind of object. What if we renamed changed_by to contributor to make that distinction clearer?
| records = list( | ||
| content_api.get_entity_draft_history(component.publishable_entity) | ||
| .select_related("entity__component__component_type") | ||
| ) | ||
| changed_by_list = resolve_contributors( | ||
| (record.draft_change_log.changed_by for record in records), request | ||
| ) | ||
|
|
||
| entries = [] | ||
| for record, changed_by in zip(records, changed_by_list, strict=False): | ||
| version = record.new_version if record.new_version is not None else record.old_version | ||
| entries.append(LibraryHistoryEntry( | ||
| changed_by=changed_by, | ||
| changed_at=record.draft_change_log.changed_at, | ||
| title=version.title if version is not None else "", | ||
| item_type=record.entity.component.component_type.name, | ||
| action=resolve_change_action(record.old_version, record.new_version), | ||
| )) | ||
| return entries |
There was a problem hiding this comment.
Could this be made into something like this?
draft_change_records = (
content_api.get_entity_draft_history(component.publishable_entity)
.select_related(
"entity__component__component_type",
"draft_change_log__changed_by",
)
)
entries = []
for record in draft_change_records:
version = record.new_version if record.new_version is not None else record.old_version
changed_by = record.draft_change_log.changed_by
if changed_by:
contributor = LibraryHistoryContributor.from_user(changed_by, request)
else:
contributor = None
entries.append(LibraryHistoryEntry(
contributor=contributor,
changed_at=record.draft_change_log.changed_at,
title=version.title if version is not None else "",
item_type=record.entity.component.component_type.name,
action=resolve_change_action(record.old_version, record.new_version),
))
return entriesIf caching is needed on the contributor construction, we could use a helper local fn like:
@cache # functools.cache
def make_contributor(user):
if user is None:
return None
return LibraryHistoryContributor.from_user(user, request)
draft_change_records = (
content_api.get_entity_draft_history(component.publishable_entity)
.select_related(
"entity__component__component_type",
"draft_change_log__changed_by",
)
)
entries = []
for record in draft_change_records:
version = record.new_version if record.new_version is not None else record.old_version
entries.append(LibraryHistoryEntry(
contributor=make_contributor(record.draft_change_log.changed_by),
changed_at=record.draft_change_log.changed_at,
title=version.title if version is not None else "",
item_type=record.entity.component.component_type.name,
action=resolve_change_action(record.old_version, record.new_version),
))
return entries…d_entity_from_record
Description
Publish history groups: Pre-Verawood vs Post-Verawood
The
openedx-contentlibrary added adirectfield toPublishLogRecordstarting in the Verawood release (openedx/openedx-core#539). This field changes how publish history groups are structured, so the endpoint handles both eras:Pre-Verawood (
PublishLogRecord.direct is None): When a container and its components are published together, each entity produces its own independent group, even though they share the samepublish_log_uuid. For example, publishing a Unit with 3 components creates 4 separate groups. Each group hasscope_entity_keyset to that specific entity's key, which the frontend must pass to the entries endpoint to fetch that entity's individual changes.Post-Verawood (
PublishLogRecord.direct is not None): Thedirectfield marks which entities the user explicitly clicked "Publish" on (direct=True) vs. which were pulled in as side effects (direct=False, e.g. a shared component published from a sibling container). In this era, all entities from the samePublishLogare merged into a single group, anddirect_published_entitieslists only the explicitly published items. Thescope_entity_keyisnull— the frontend uses the current container key to fetch entries.This design means the frontend does not need era awareness: it always uses
group.scope_entity_key ?? currentContainerKeywhen calling the entries endpoint.Functions
get_library_component_draft_history: Return the draft change history for a library component since its last publication.get_library_component_publish_history: Return the publish history of a library component as a list of groups.get_library_component_publish_history_entries: Return the individual draft change entries for a specific publish event.get_library_component_creation_entry: Return the creation entry (who created it and when).get_library_container_draft_history: Return the combined draft history for a container and all of its descendant components.get_library_container_publish_history: Return the publish history of a container as a list of groups.get_library_container_publish_history_entries: Return the individual draft change entries for the container entity in a specific publish event.get_library_container_creation_entry: Return the creation entry for a container.List of features (TODOs):
Supporting information
history logapi functions [FC-0123] openedx-core#501Testing instructions
Follow the testing instructions in openedx/frontend-app-authoring#2948
Deadline
Before the Verawood cut.
Other information
N/A